Make Brighter logging instance-scoped and explicit - #4185
Conversation
Code Review: Instance-scoped logging (deprecate static
|
Review: Instance-scoped logging (deprecate static
|
Review: Make Brighter logging instance-scoped (#4185)Thanks for tackling this — routing logging through a process-wide static was a genuine latent bug, and the writeup of the two failure modes (last-writer-wins cross-talk + use-after-dispose on container disposal) is spot on. The overall approach is sound and the execution is consistent across a large surface (175 files). A few notes below, mostly minor. What works well
Observations / questions
Verification
Nice, careful piece of work overall. The message-creator gap (#1) is the only thing I'd want resolved or explicitly documented before merge; the rest are polish. 🤖 Generated with Claude Code |
|
Alright, we will need to look over this one carefully, as it was previously a choice to avoid passing loggers throughout the application. It may not be a breaking change, but it may change behavior for V10 users who don't transition, potentially resulting in a silent failure. That would mean, IMO, it would be V11, and it would be preferable to force success rather than allow silent failure. In addition, it may change log messages. Not necessarily adverse, but didn't really want to trigger V11 work until SEP/OCT |
|
Agreed it's a breaking change for anyone manually constructing these classes. We can hold off until V11 if you were happy with this change and park for now if needed. For MEDI users I don't believe they'd have to tweak any code. But also happy to discuss the design further. The current behaviour is a problem though for any two or more brighter's running under the same process. We noticed it earlier in a test suite. What do you mean by force success btw? Remove the NullLogger fallback? |
Review: Make Brighter logging instance-scoped (#4185)Thanks for tackling this — the use-after-dispose and cross-talk caused by the process-wide static were real, nasty bugs (especially under parallel test suites), and the instance-scoped approach is the right fix. The refactor is large but overwhelmingly mechanical and consistent: A few things worth addressing or at least calling out: 1. Message creators silently lose logging entirely (behavioural regression, not in the PR description)
private static readonly ILogger s_logger = NullLogger<RmqMessageCreator>.Instance;Under DI, the old static was populated with the container's real factory, so these creators previously logged through it. Now they log to a no-op for everyone, with no way to ever supply a real logger. The messages being silenced are exactly the diagnostics an operator wants —
If silencing these is intentional, it should at least be noted in the PR's "Behaviour change" section; right now that section only mentions user-constructed transports/stores falling back to no-op, not these always-on-no-op leaf objects. 2. Interface addition is a breaking change for implementers (minor)
3. Per-message
|
There was a problem hiding this comment.
Code Health Improved
(3 files improve in Code Health)
Gates Failed
Prevent hotspot decline
(2 hotspots with Complex Method)
Enforce advisory code health rules
(4 files with Complex Method, Constructor Over-Injection, Code Duplication)
Our agent can fix these. Install it.
Gates Passed
2 Quality Gates Passed
Reason for failure
| Prevent hotspot decline | Violations | Code Health Impact | |
|---|---|---|---|
| OutboxProducerMediator.cs | 1 rule in this hotspot | 6.97 → 6.95 | Suppress |
| KafkaMessageConsumer.cs | 1 rule in this hotspot | 6.87 → 6.86 | Suppress |
| Enforce advisory code health rules | Violations | Code Health Impact | |
|---|---|---|---|
| PipelineBuilder.cs | 1 advisory rule | 7.79 → 7.33 | Suppress |
| MessagePump.cs | 1 advisory rule | 10.00 → 9.69 | Suppress |
| OutboxProducerMediator.cs | 1 advisory rule | 6.97 → 6.95 | Suppress |
| KafkaMessageConsumer.cs | 1 advisory rule | 6.87 → 6.86 | Suppress |
View Improvements
| File | Code Health Impact | Categories Improved |
|---|---|---|
| SqsInlineMessageCreator.cs | 8.96 → 9.54 | Overall Code Complexity |
| S3LuggageStore.cs | 8.60 → 8.88 | Primitive Obsession, String Heavy Function Arguments |
| S3LuggageStore.cs | 8.60 → 8.88 | Primitive Obsession, String Heavy Function Arguments |
Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.
|
Review: Instance-scoped logging (deprecate static Thanks for tackling this — moving off the process-wide static is the right call, and issue #4184 is a real correctness bug (cross-talk + use-after-dispose between Brighter instances). The approach is clean: trailing optional A few observations, mostly minor, plus one I would like you to confirm is intentional. 1. Base
2.
3.
Test coverage The new Minor
Overall this is a solid, well-scoped refactor that fixes a genuine bug with good backward-compat discipline. The main thing I would want resolved before merge is confirming whether the base-handler relay logging going silent (point 1) is intended, and ideally a test that exercises a real handler pipeline. Automated review — verified against the merge of this branch into master; I was unable to run the build/tests in this environment, so the compile-clean claim is taken on trust. |
Yes, because I get that stops users having compilation issues, but it feels like it could silently fail on folks who where not expecting it. |
I think it may help with some other aspects like testing too. We currently have to use a test capture context and I am not sure that is the best route |
Replace the process-wide static ApplicationLogging.LoggerFactory with an ILoggerFactory threaded as an instance through the object graph Brighter constructs. The DI extensions no longer copy the container's ILoggerFactory into the static, which caused use-after-dispose when a container was disposed and cross-talk between two Brighter instances in the same process. - CommandProcessorBuilder/DispatchBuilder gain ConfigureLogging(ILoggerFactory) - Runtime types (CommandProcessor, OutboxProducerMediator, Dispatcher, MessagePump, Reactor/Proactor, pipelines, scheduler) take an optional trailing ILoggerFactory; relational stores take an optional ILogger - Transport/store factories thread the factory down to the leaf objects - ApplicationLogging is [Obsolete], defaults to NullLoggerFactory and tolerates a disposed factory; nothing in Brighter writes to it any more - s_logger static fields become instance _logger fields Adds InstanceScopedLoggingTests (crosstalk isolation, sibling-dispose safety, null-factory fallback) and a migration guide. Existing call sites remain source-compatible via trailing optional parameters.
Adopt the idiomatic MEDI logging shape (ILogger<T>) where a class logs only on its own behalf and is either resolved from the container or constructed directly: - Relational Outbox/Inbox stores: ILogger? -> ILogger<TStore>? - DI-resolved handlers (Defer/Reject/UseInbox/RequestLogging, sync + async, and ConfigurationCommandHandler): ILoggerFactory -> ILogger<THandler> - Hosted services (TimedOutboxSweeper, TimedOutboxArchiver): ILoggerFactory -> ILogger<TService> (also corrects TimedOutboxArchiver, which was logging under the TimedOutboxSweeper category) Builders, factories and aggregates that mint loggers for child components (CommandProcessor, OutboxProducerMediator, Dispatcher, MessagePump, ConsumerFactory, transport producer/consumer/channel factories, etc.) keep ILoggerFactory, since they create loggers for types other than themselves. All new parameters remain trailing and optional, so call sites stay source-compatible.
…ings) Quality cleanup from a /simplify pass over the instance-scoped logging work: - Message creators (Kafka, RMQ sync+async, Redis): use NullLogger<T>.Instance instead of NullLoggerFactory.Instance.CreateLogger<T>() (no allocation, matches the idiom in ApplicationLogging). - AzureServiceBusChannelFactory: drop the unused ILoggerFactory parameter/field (it logged nothing and threaded nothing). - MsSql ChannelFactory: take ILogger<ChannelFactory> rather than ILoggerFactory (it only logs for itself, so it is a leaf). - Remove 27 now-dead `using Paramore.Brighter.Logging;` imports left behind after the ApplicationLogging usages were removed. - Clarify the ApplicationLogging [Obsolete] remarks wording.
…gap) The Rmq/Kafka/Redis message creators previously held a permanent no-op NullLogger, so their diagnostics (malformed/missing-header warnings and deserialize failures) went nowhere after the move off the static logger. Convert each to an instance class whose ILogger<T> is created once by the owning consumer (Kafka/Redis via their ILoggerFactory, RMQ via the gateway's LoggerFactory) and reused for every message, so those log lines reach the configured logger again. Message-building logic is unchanged; only the previously-silent diagnostics are restored.
Remove implicit NullLogger fallbacks and the ApplicationLogging shim. Update callers to provide real or explicitly null logger factories.
5ae5396 to
ef71711
Compare
Review: Make Brighter logging instance-scoped and explicitThanks for tackling this — the underlying bug is real and nasty (a process-wide static
1. The samples now silence the logging this PR exists to protect122 sites across // samples/WebAPI/WebAPI_Common/TransportMaker/ConfigureTransport.cs:83
loggerFactory: global::Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)The PR's stated goal is "missing logger configuration now throws instead of silently disabling logs" — but the samples are the primary documentation for how to wire Brighter, and they now demonstrate exactly the silent-no-op outcome the change set out to eliminate. These are host-builder apps with a real Please thread the host's factory through the samples. Where one genuinely can't (a static helper with no host in scope), a one-line comment explaining the deliberate opt-out would help. Minor, but 1,597 occurrences repo-wide: the fully-qualified 2. Logger category mismatchesThe sweep makes these visible; several loggers are created under the wrong category, which breaks per-category filtering for anyone configuring
Some likely carried over verbatim from the old 3. No public escape hatch for a directly constructed handler
The problem is outside the framework. Constructing a handler chain by hand is a very common user unit-testing pattern: var handler = new MyHandler();
handler.SetSuccessor(new MyOtherHandler());
handler.Handle(command); // base.Handle -> Log.PassingRequestFromTo(Logger, ...) -> throwsThat now throws, and because the interface is internal there is no public API a user can call to fix it. Turning a logging concern into a hard failure in a branch users can't reach is a rough edge. Options, roughly in order of preference: make 4. The two new fail-fast paths are untestedThe PR body highlights "Require Also worth considering: make logging a stage in the progressive builder interface ( 5. Error-message quality on the DI path
6. XML documentation190 new 7. Migration notes understate the breakIt isn't only "a new required parameter" — parameter order changed on public constructors. 8. No ADRCLAUDE.md mandates 9. Hot-path
|
Review: Make Brighter logging instance-scoped and explicitKilling the mutable process-wide Below are the things I'd want addressed before merge, roughly by priority. (Comment 1 of 2 — correctness/perf here, scope/process in the follow-up.) 1. Handlers throw
|
| Site | Frequency |
|---|---|
PipelineBuilder.cs:107 |
per Send/SendAsync/Publish/PublishAsync |
HandlerLifetimeScope.cs:53 |
per instance scope, i.e. per request |
ConfigureLogging → RequestHandler.cs:59 |
per handler and per decorator |
WrapPipeline.cs:62 / UnwrapPipeline |
per message (BuildWrapPipeline at OutboxProducerMediator.cs:1281, :1345) |
TransformLifetimeScope.cs:16 / ...Async.cs:17 |
per message |
A pipeline with a few attribute decorators now takes ~8-10 factory locks per message where it took zero. Under Publish fan-out and multiple pumps sharing one root factory that's a shared contention point on the throughput path.
Could you run the BenchmarkDotNet suite before/after? If it moves, two cheap fixes: have PipelineBuilder create the handler ILogger once in its ctor and change IRequireLoggerFactory.ConfigureLogging to take the ILogger; and memoise per-type loggers against the factory (e.g. ConditionalWeakTable<ILoggerFactory, ...>), keeping instance scoping while restoring the old amortisation.
3. DispatchBuilder XML doc contradicts the implementation
DispatchBuilder.cs:293 says "If not called, a no-op logger factory is used." — but Build() at :186 throws ConfigurationException. The doc describes the behaviour you deliberately removed. CommandProcessorBuilder.cs:501-503 gets it right.
4. Enforce logging at compile time, not runtime
Both builders use Brighter's progressive-interface idiom (INeedAHandlers → INeedResilience → INeedMessaging → INeedInstrumentation → INeedARequestSchedulerFactory → IAmACommandProcessorBuilder) precisely so required steps are compiler-enforced. Putting ConfigureLogging on the terminal interface makes a newly-mandatory step invisible to the compiler and defers the error to first boot — the one thing that design exists to avoid. An INeedLogging stage (or StartNew(loggerFactory)) turns "you forgot logging" into a build error, which is a far better upgrade experience.
5. Missing null guards on the new ILoggerFactory parameters
Only CommandProcessorBuilder.cs:285 and DispatchBuilder.cs:142 guard. CommandProcessor.cs:167, Dispatcher.cs:154, OutboxProducerMediator.cs:146 and every gateway consumer/producer do a bare loggerFactory.CreateLogger<T>(). For callers without NRTs (or from F#/VB) that's an opaque NullReferenceException inside a constructor rather than ArgumentNullException(nameof(loggerFactory)). Given fail-fast is the thesis, worth being consistent at least at the public entry points.
6. Three injection styles for the same dependency
New public API is hard to change later, so worth settling now:
ILoggerFactory— the majority- plain
ILogger—TransformerFactory.cs:31,TransformerFactoryAsync,RmqMessageCreator,KafkaMessageCreator,PostgreSqlBoxDetectionHelper ILogger<T>—MsSql/ChannelFactory.cs:33,MsSqlOutbox.cs:51,PostgreSqlOutbox,MySqlOutbox,SqliteOutbox
The plain-ILogger form also miscategorises: TransformPipelineBuilder.cs:198 passes its own _logger into TransformerFactory, so FailedToReleaseTransformerAfterInitFailure logs under category TransformPipelineBuilder. Passing _loggerFactory fixes it.
7. Logger-category policy is inconsistent
I diffed every CreateLogger<T> old vs new — categories are faithfully preserved almost everywhere, including odd pre-existing ones (KafkaMessagingGateway → KafkaMessageProducer, AzureBlobLockingProvider → AzureBlobLockingProviderOptions). But TransformPipelineBuilderAsync.cs:87 was silently corrected to TransformPipelineBuilderAsync, while OutboxProducerMediator.cs:146 kept CreateLogger<CommandProcessor>(). Fix all or none — and either way category renames break consumers' appsettings.json log-level filters, so they need a release note.
8. protected fields named _logger break the project's style guide
.agent_instructions/code_style.md:5 — "Use PascalCase for public and protected members". Five violations on public abstract bases (so they are API): MessagePump.cs:75, Mediator/Steps.cs:61, KafkaMessagingGateway.cs:43-44, AWSSQS/AWSMessagingGateway.cs:48, AWSSQS.V4/AWSMessagingGateway.cs:48. RmqMessageGateway.cs:61 (LoggerFactory) and SqlBoxMigrationRunner.cs:65 (Logger) get it right.
|
(Comment 2 of 2 — scope, samples, tests, process.) 9. Scope creepCLAUDE.md: "Do NOT change defaults or make changes beyond what was explicitly requested... no additional 'improvements' or default value changes."
The formatter also produced this in ~10 places, which is worse than what it replaced: try
{ CleanUpAfterFailedBuild(pipeline, transformLeases, messageMapperLease); }
catch (Exception cleanupException) { Log.FailedToCleanUpAfterFailedBuild(_logger, cleanupException); }Per 10. Samples now teach users to disable logging, in unreadable code
return (new PostgreSqlOutbox(configuration, logger: global::Microsoft.Extensions.Logging.LoggerFactoryExtensions.CreateLogger<global::Paramore.Brighter.Outbox.PostgreSql.PostgreSqlOutbox>(global::Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)), ...);This is machine-generated output (fully-qualified The semantic problem is bigger than the formatting one: samples are the primary documentation, and every one now demonstrates how to turn Brighter's logging off. Someone copy-pasting 11. Test coverage gaps on the headline behaviour change
Per CLAUDE.md's TDD workflow these should have been driven by 12. ADRs and migration docsTwo accepted ADRs were retro-edited rather than superseded: There's also no migration/upgrade doc for a change that breaks nearly every public constructor in the library. The PR body's "Behaviour change" section is a good start; it should live in SecurityNothing concerning. No new secret or PII surface — this moves logger plumbing, not payloads. If anything, removing a globally-settable SummaryGood idea, thorough sweep, and the right tests for the DI paths. My blockers are §1 (handlers throwing from a log statement) and §3 (doc contradicting behaviour); §2 wants a benchmark before merge; §9 and §10 are the ones I'd most like split out, since ~3k lines of formatting noise plus 672 machine-mangled test/sample files make a breaking change materially harder to both review and adopt. |
Closes #4184
Problem
Logging was routed through the process-wide static
ApplicationLogging.LoggerFactory. Multiple Brighter instances could log through the wrong service provider and break each other when one provider was disposed.Implicit
NullLoggerfallbacks also hid incomplete logging configuration.Change
Logging is now instance-scoped and explicit:
ApplicationLoggingcompatibility shim.ILoggerFactory/ILoggerdependencies throughout the runtime object graph instead of defaulting toNullLogger.ConfigureLogging(ILoggerFactory)beforeCommandProcessorBuilder.Build()andDispatchBuilder.Build().ILoggerFactorywithGetRequiredServicein DI paths so missing logging configuration fails fast.NullLoggerFactory.Instanceexplicitly.Behaviour change
Missing logger configuration now throws instead of silently disabling logs. Applications that intentionally want no logging must explicitly supply or register
NullLoggerFactory.Instance.Tests
Paramore.Brighter.Core.Tests: 978 passed, 7 skipped on both net9.0 and net10.0.Paramore.Brighter.Extensions.Tests: 142 passed on both target frameworks.Paramore.Brighter.InMemory.Tests: 153 passed on both target frameworks.Paramore.Brighter.BoxProvisioning.Tests: 111 passed on both target frameworks.